const express = require("express");
const app = express();
// https://www.npmjs.com/package/async-lock
const AsyncLock = require('async-lock');
const lock = new AsyncLock();
const axios = require('axios').default;

var child_process = require("child_process");
const fs = require("fs");
var path = require("path");
var zlib = require("zlib");
var buffer = require("buffer").Buffer;

app.use(express.json());

const PORT = 9000;
const CACHE_DIR = "cache";

// does cached repo need auth?
const AUTH_REQUIRED = false;

// auto update credential on push events
const REPO_UPDATE_USER = "user";
const REPO_UPDATE_PASS = "pass";

app.get('/git/notifyCommit', function (req, res) {
  console.log(`[INFO] [REPO_UPDATED] repo: ${req.query.url} branch: ${req.query.branches} sha1: ${req.query.sha1}`);

  var token = Buffer.from(`${REPO_UPDATE_USER}:${REPO_UPDATE_PASS}`).toString('base64');
  var repo = decodeURI(req.query.url).replace("https://", "").toLowerCase();
  var params = {
    repo: repo,
    authToken: token
  };

  syncRepo(params, true, () => {
    res.send('ok');
  });
});

class NotSupportedError extends Error {
  constructor(message = "", ...args) {
    super(message, ...args);
    this.message = message;
  }
}

function getParams(req) {
  var regex = new RegExp("^/(.+)(.git)?/(info/refs\\?service=)?(git-[^-]+-pack)$");

  var url = req.url.toLowerCase();
  var index = url.indexOf("@");

  var user = null;
  var pass = null;
  var matches = null;
  // url contains credential
  if ( index > 0) {
    var creds = url.substr(1, index - 1);
    url = "/" + url.substr(index + 1);

    matches = creds.match(/(.*):(.*)/);
    if (matches) {
      user = matches[1];
      pass = matches[2];
    }
  }

  matches = regex.exec(url);
  if(!matches) {
    throw new NotSupportedError(`Cannot deal with url ${req.url}`);
  }

  var token = null;

  if(AUTH_REQUIRED) {
    if (req.headers["authorization"])
      token = req.headers["authorization"].substr(6);
    else if (user != null && pass != null) {
      token = Buffer.from(`${user}:${pass}`).toString('base64');
    }
  } else {
    token = Buffer.from(`${REPO_UPDATE_USER}:${REPO_UPDATE_PASS}`).toString('base64');
  }

  return { repo: matches[1], authToken: token, service: matches[4], isInfoRequest: matches[3] != null};
}

function authenticate(params, callback) {
  if (AUTH_REQUIRED) {
    var url = `https://${params.repo}/info/refs?service=${params.service}`;
    console.log(`authenticating on the upstream repo for ${url}`);
    axios.get(url, {headers: {"User-Agent": "git/", "Accept": "*/*", "Authorization": `Basic ${params.authToken}`}})
      .then((response)=>callback(response))
      .catch((error)=>callback(error.response));
  } else {
    callback({
      status: 200
    });
  }
}

function syncRepo(params, fetch, callback) {
  // sync repo with service user
  // user provided credentials if only for authorization for repo
  var remote = `https://${REPO_UPDATE_USER}:${REPO_UPDATE_PASS}@${params.repo}`;
  // if (params.authToken != null) {
  //   var auth = Buffer.from(params.authToken, 'base64').toString('utf-8');
  //   remote = `https://${auth}@${params.repo}`;
  // } else {
  //   remote = `https://${params.repo}`;
  // }
  var local = path.join(CACHE_DIR, params.repo);

  lock.acquire(params.repo, (releaseLock) =>  {
    if (!fs.existsSync(local)) {
      // clone when not exist
      // add -c pack.window=1 to avoid unexpected error for large repo (take more than 30 mins to download)
      child_process.exec(`git clone --quiet --mirror -c pack.window=1 "${remote}" "${local}"`, (error, stdout, stderr) => {
        releaseLock();

        if (error !== null) {
          console.log(`[ERROR] git clone for ${params.repo} error: ${error} stderr: ${stderr}`);
          // remove folder
          fs.rmSync(local, {force: true, recursive: true});
        } else {
          console.log(`[INFO] git clone done for ${params.repo}`);
        }

        callback( );
      });
    } else if (fetch) {
      //fetch on push events
      child_process.exec(`git -C "${local}" fetch --quiet`,  (error, stdout, stderr) => {
        releaseLock();

        if (error !== null) {
          console.log(`[ERROR] git fetch for ${params.repo} error: ${error} stderr: ${stderr}`);
        } else {
          console.log(`[INFO] git fetch done for ${params.repo}`);
        }

        callback();
      });
    } else {
      releaseLock();
      callback();
    }
  }).catch((err, ret) => {
    console.log(`[ERROR] ${err.message}`);
   });
}

function sendFullResponse(res, service, repo) {
  var local = path.join(CACHE_DIR, repo);

  res.statusCode = 200;
  res.setHeader("Content-Type","application/x-" + service + "-advertisement");
  res.setHeader("Cache-Control","no-cache");
  res.write("001e# service=git-upload-pack\n0000");

  var process = child_process.spawn(service, ["--stateless-rpc", "--advertise-refs", local]);
  process.stdout.pipe(res);
  process.stderr.on("data",function(data) {
    console.log("" + service + " stderr: " + data);
  });
  process.on("exit",function(code) {
    if(code != 0) {
      res.end();
    }
    console.log("[InfoRequest] " + service + " done with exit " + code);
  });
}

function sendDeltalResponse(req, res, service, repo) {
  var local = path.join(CACHE_DIR, repo);
  res.statusCode = 200;
  res.setHeader("Content-Type","application/x-" + service + "-result");
  res.setHeader("Cache-Control","no-cache");

  var process = child_process.spawn(service, ["--stateless-rpc", local]);
  if(req.headers["content-encoding"] == "gzip") {
    var tmp = zlib.createUnzip();
    req.pipe(tmp).pipe(process.stdin);
  } else {
    req.pipe(process.stdin);
  }

  process.stdout.pipe(res);
  process.stderr.on("data",function(data) {
    console.log("[ERROR] " + service + " stderr: " + data);
  });
  process.on("exit",function(code) {
    if(code != 0) {
      res.end();
    }
    console.log("[Non-InfoRequest] " + service + " done with exit " + code);
  });
}

function handleRequest(req, res) {
  try {
    var ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || null;
    var params = getParams(req);

    console.log(`[${ip}] ${req.method} ${params.repo}`);
    //console.log(params);
    if(params.service != "git-upload-pack") {
			throw new NotSupportedError(`[ERROR] Service ${params.service} not supported yet`);
		}

    authenticate(params, (response)=>{
      switch(response.status) {
        case 200:
          break;
        case 401:case 403:case 404:
          res.writeHead(response.status, response.headers);
          res.end();
          return;
      }

      syncRepo(params, false, () => {
        if(params.isInfoRequest) {
          sendFullResponse(res, params.service, params.repo);
        } else {
          sendDeltalResponse(req, res, params.service, params.repo);
        }
      });

    });
  }
  catch(err) {
    console.log(err);
  }
}

app.get('*', function (req, res) {
  handleRequest(req, res);
});

app.post('*', function (req, res) {
  handleRequest(req, res);
});

app.listen(PORT, () => {
  if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR);
  console.log(`listening on port ${PORT}!`);
});


